/**
 * @module
 * This module enables JSX to supports streaming Response.
 */

import { raw } from '../helper/html'
import { HtmlEscapedCallbackPhase, resolveCallback } from '../utils/html'
import type { HtmlEscapedString } from '../utils/html'
import { JSXNode } from './base'
import { childrenToString } from './components'
import { DOM_RENDERER, DOM_STASH } from './constants'
import { createContext, useContext } from './context'
import { Suspense as SuspenseDomRenderer } from './dom/components'
import { buildDataStack } from './dom/render'
import type { HasRenderToDom, NodeObject } from './dom/render'
import type { Child, FC, PropsWithChildren, Context as JSXContext } from './'

/**
 * Used to specify nonce for scripts generated by `Suspense` and `ErrorBoundary`.
 *
 * @example
 * ```tsx
 * <StreamingContext.Provider value={{ scriptNonce: 'test-nonce' }}>
 *   <Suspense fallback={<p>Loading...</p>}>
 *     <Content />
 *   </Suspense>
 * </StreamingContext.Provider>
 * ```
 */
export const StreamingContext: JSXContext<{ scriptNonce: string } | null> = createContext<{
  scriptNonce: string
} | null>(null)

let suspenseCounter = 0

/**
 * @experimental
 * `Suspense` is an experimental feature.
 * The API might be changed.
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const Suspense: FC<PropsWithChildren<{ fallback: any }>> = async ({
  children,
  fallback,
}) => {
  if (!children) {
    return fallback.toString()
  }
  if (!Array.isArray(children)) {
    children = [children]
  }

  const nonce = useContext(StreamingContext)?.scriptNonce

  let resArray: HtmlEscapedString[] | Promise<HtmlEscapedString[]>[] = []

  // for use() hook
  const stackNode = { [DOM_STASH]: [0, []] } as unknown as NodeObject
  const popNodeStack = (value?: unknown) => {
    buildDataStack.pop()
    return value
  }

  try {
    stackNode[DOM_STASH][0] = 0
    buildDataStack.push([[], stackNode])
    resArray = children.map((c) =>
      c == null || typeof c === 'boolean' ? '' : c.toString()
    ) as HtmlEscapedString[]
  } catch (e) {
    if (e instanceof Promise) {
      resArray = [
        e.then(() => {
          stackNode[DOM_STASH][0] = 0
          buildDataStack.push([[], stackNode])
          return childrenToString(children as Child[]).then(popNodeStack)
        }),
      ] as Promise<HtmlEscapedString[]>[]
    } else {
      throw e
    }
  } finally {
    popNodeStack()
  }

  if (resArray.some((res) => (res as {}) instanceof Promise)) {
    const index = suspenseCounter++
    const fallbackStr = await fallback.toString()
    return raw(`<template id="H:${index}"></template>${fallbackStr}<!--/$-->`, [
      ...(fallbackStr.callbacks || []),
      ({ phase, buffer, context }) => {
        if (phase === HtmlEscapedCallbackPhase.BeforeStream) {
          return
        }
        return Promise.all(resArray).then(async (htmlArray) => {
          htmlArray = htmlArray.flat()
          const content = htmlArray.join('')
          if (buffer) {
            buffer[0] = buffer[0].replace(
              new RegExp(`<template id="H:${index}"></template>.*?<!--/\\$-->`),
              content
            )
          }
          let html = buffer
            ? ''
            : `<template data-hono-target="H:${index}">${content}</template><script${
                nonce ? ` nonce="${nonce}"` : ''
              }>
((d,c,n) => {
c=d.currentScript.previousSibling
d=d.getElementById('H:${index}')
if(!d)return
do{n=d.nextSibling;n.remove()}while(n.nodeType!=8||n.nodeValue!='/$')
d.replaceWith(c.content)
})(document)
</script>`

          const callbacks = htmlArray
            .map((html) => (html as HtmlEscapedString).callbacks || [])
            .flat()
          if (!callbacks.length) {
            return html
          }

          if (phase === HtmlEscapedCallbackPhase.Stream) {
            html = await resolveCallback(html, HtmlEscapedCallbackPhase.BeforeStream, true, context)
          }

          return raw(html, callbacks)
        })
      },
    ])
  } else {
    return raw(resArray.join(''))
  }
}
;(Suspense as HasRenderToDom)[DOM_RENDERER] = SuspenseDomRenderer

const textEncoder = new TextEncoder()
/**
 * @experimental
 * `renderToReadableStream()` is an experimental feature.
 * The API might be changed.
 */
export const renderToReadableStream = (
  content: HtmlEscapedString | JSXNode | Promise<HtmlEscapedString>,
  onError: (e: unknown) => string | void = console.trace
): ReadableStream<Uint8Array> => {
  const reader = new ReadableStream<Uint8Array>({
    async start(controller) {
      try {
        if (content instanceof JSXNode) {
          // aJSXNode.toString() returns a string or Promise<string> and string is already escaped
          content = content.toString() as HtmlEscapedString | Promise<HtmlEscapedString>
        }
        const context = typeof content === 'object' ? content : {}
        const resolved = await resolveCallback(
          content,
          HtmlEscapedCallbackPhase.BeforeStream,
          true,
          context
        )
        controller.enqueue(textEncoder.encode(resolved))

        let resolvedCount = 0
        const callbacks: Promise<void>[] = []
        const then = (promise: Promise<string>) => {
          callbacks.push(
            promise
              .catch((err) => {
                console.log(err)
                onError(err)
                return ''
              })
              .then(async (res) => {
                res = await resolveCallback(
                  res,
                  HtmlEscapedCallbackPhase.BeforeStream,
                  true,
                  context
                )
                ;(res as HtmlEscapedString).callbacks
                  ?.map((c) => c({ phase: HtmlEscapedCallbackPhase.Stream, context }))
                  // eslint-disable-next-line @typescript-eslint/no-explicit-any
                  .filter<Promise<string>>(Boolean as any)
                  .forEach(then)
                resolvedCount++
                controller.enqueue(textEncoder.encode(res))
              })
          )
        }
        ;(resolved as HtmlEscapedString).callbacks
          ?.map((c) => c({ phase: HtmlEscapedCallbackPhase.Stream, context }))
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          .filter<Promise<string>>(Boolean as any)
          .forEach(then)
        while (resolvedCount !== callbacks.length) {
          await Promise.all(callbacks)
        }
      } catch (e) {
        // maybe the connection was closed
        onError(e)
      }

      controller.close()
    },
  })
  return reader
}
